
Day 24 那天把 agent 接進服務,重點全在「怎麼讓一個不能改的東西跑進來」。它原本直接抓著向量資料庫的物件不放,而服務這一側早就把檢索換成一個介面了,所以我包了一個轉接頭,讓它以為自己還在對著資料庫講話。接起來能問、答案跟腳本時代一樣,我就收工了。
今天要加上限,第一個動作是去找「它最多能走幾步」這個參數。
沒有這個參數。呼叫端能傳的只有兩個東西,要撈幾條條文、以及要不要強迫它交卷前自驗。而「它可以走多遠」寫在那支不能改的檔案裡,是一行模組層級的常數:
MAX_STEPS = 6
那個 6 是我三個星期前在腳本裡隨手挑的。腳本時代這樣沒問題,跑的是我自己,我看它繞太久大不了按 Ctrl-C。變成服務之後,按 Ctrl-C 的人不在現場。
平常不會出事,因為大部分問題兩步就結束:查一次、答一次。會出事的是規章裡沒有答案的那種問題。它不會自己放棄,只會換個說法再查一次,查到撞上限為止。Day 21 量過,28 個題目裡有 3 個一路走到那個 6。
而那個 6 從外面看不到也改不了。所以我現在有一個能公開呼叫的端點,裡面跑著一個我無法從外面喊停的迴圈。
第一版的想法很單純:數步數,超過就停。寫到一半卡在一個問題上:停下來之後,我要回什麼給呼叫端?
去翻那支 agent 的迴圈,發現它自己就有一條停下來的路。走到第 6 步還沒交卷的時候,它會把一句話推給模型,大意是「你的步數用完了,請直接用手上的資料回答」。也就是說,只要我把 6 改小,就是提早對它說這句話,而它走完這條路是有答案的。
那時間上限呢?時間用完的那一刻,我不能再多花一次呼叫去請它收尾,那一次呼叫正是我要省的東西。剩下的只有一種做法:在它要去問模型之前直接把程式打斷,不讓那次呼叫發生。這種停法沒有收尾,它連一個字都還沒開始寫。
同一個「上限」底下是兩種語意:
| 上限 | 怎麼停 | 停下來還有答案嗎 |
|---|---|---|
| 步數 | 提早跟它說「請你現在交卷」 | 有 |
| 時間 | 下一次問模型之前結算,超過就當場中斷 | 沒有 |
| 金額 | 同上,改看這一題已經花掉多少 | 沒有 |
所以要寫的東西變成圖上那兩個出口,而停下來的方式決定了呼叫端拿到什麼。金額那個跟時間走同一條路,今天不展開。
這個模組我先寫的不是怎麼限制,而是怎麼在沒人設上限的時候完全不作用。
理由有兩個。一個是這支服務從 Day 14 到現在都是沒有上限在跑的,我今天要是讓預設行為變了一點點,前面每一天量到的東西就不能拿來跟以後比。另一個是這種功能一旦預設就開,別人升級上去才發現自己的請求被砍了一半,那是很討厭的事。
所以三個上限的預設值都是「沒有」,沒人設的時候,這個檔案在請求路徑上只做一件事:確認三個欄位都是空的,然後放行。
@dataclass(frozen=True)
class Budget:
"""一次 agent 請求的三個上限。全 None = 不設限。"""
max_steps: int | None = None
max_twd: float | None = None
max_wall_ms: float | None = None
Day 24 那天我在設定檔裡寫過一句話:不設任何環境變數時,這支服務的行為跟前面每一天完全相同。要不要有上限、上限多少,是部署的人的決定。
那麼有人設的時候,聽誰的?圖上最上面那一格解的就是這件事。上限有兩個來源,一個是呼叫端在這次請求裡給的,一個是服務端用環境變數設的天花板。兩邊都可能沒設,也可能都設了,所以要有一條規則:取緊的那個,而且呼叫端不能把自己的上限設得比服務允許的更寬。
def _tighter(asked, ceiling):
"""呼叫端要的跟服務允許的,取緊的那個。服務沒設就用呼叫端的。"""
if ceiling in (None, 0):
return asked
if asked in (None, 0):
return ceiling
return min(asked, ceiling)
0 跟 None 在這裡是同一個意思,因為環境變數讀出來是數字,「沒設」只能用 0 表示。這種小地方寫成一個有名字的函式,比散在三個 min() 裡好,測試也才咬得住。請求那一側再加三個選填欄位,範圍直接寫在欄位上,超出範圍的請求在碰到 agent 之前就被擋掉:
max_steps: int | None = Field(
default=None, ge=1, le=12,
description="只對 agent 有效。它最多可以走幾步;走到就被要求直接作答")
服務端的天花板走環境變數,三個預設都是 0,也就是不設限:
| 變數 | 預設 | 意思 |
|---|---|---|
DAY24_AGENT_MAX_STEPS |
0 |
它最多走幾步 |
DAY24_AGENT_MAX_TWD |
0 |
一題最多花多少 |
DAY24_AGENT_MAX_WALL_MS |
0 |
一題最多跑多久 |
打起來是這樣:
curl -s -X POST http://127.0.0.1:8024/ask \
-H 'Content-Type: application/json' \
-d '{"question":"公司有健身房嗎?","mode":"agent","max_steps":3}'
圖裡面那一格「每一次問模型之前,先問一句」,實作上只有一行。
Day 24 為了量時間,寫過一層包裝:進到 agent 之前,把它對外的三個出口暫時換成我的版本,離開時換回去。今天的檢查就掛在其中一個出口的最前面,也就是它每次要去問模型的那個動作:
def chat_tools(messages, *args, **kwargs):
# 上限只在這裡檢查。問模型是這支 agent 唯一會「花掉時間與錢」的動作,
# 其餘(查規章、算向量)都在它的陰影底下
if guard is not None:
guard.check()
...
檢查本身很短,兩個 if 而已:
def check(self) -> None:
"""下一次問模型之前。超過就中斷,沒超過就放行。"""
self.calls += 1
if self.budget.max_wall_ms is not None \
and self.elapsed_ms >= self.budget.max_wall_ms:
raise BudgetExceeded("time", self.calls, self.spent_twd, self.elapsed_ms)
只掛在問模型之前,有一個直接的後果要寫進文件:實際耗時與實際花費會超過上限,最多超過一次呼叫的量。上限是在下一次呼叫之前結算的,要分毫不差就得在呼叫出去之前知道那一次會跑多久,我沒有辦法。
另外,中斷用的那個例外刻意不掛在服務既有的錯誤家族底下。掛上去的話它會被錯誤處理器接走,變成一個 4xx 或 5xx 的回應,而我還沒想好被上限攔下來算不算錯誤。
圖左邊那個出口沒有新東西要處理。把上限換小、讓那支 agent 走它自己那條交卷的路,回傳值是完整的,用完再換回去就好:
@contextlib.contextmanager
def step_cap(max_steps: int | None):
if max_steps is None:
yield
return
original = agent21.MAX_STEPS
agent21.MAX_STEPS = max(1, int(max_steps))
try:
yield
finally:
agent21.MAX_STEPS = original
在外面改模組上的常數、用完還原,看起來很土,但它是「不能改那支檔案」這個限制下唯一乾淨的做法。Day 24 換「要撈幾條條文」的時候就是這樣做的,今天只是多一個。
先講正常結束的時候會發生什麼事。那支 agent 跑完一題,最後會回一包東西給我,裡面有它寫的答案、它一路查到的條號、查了幾次、問了模型幾次。服務就是拿這包東西去組回應的。
打斷的做法是在它要去問模型之前丟出一個例外。例外一丟,程式就從迴圈中間跳出去,那支 agent 的函式根本沒跑到最後那一行 return。也就是說,上面講的那包東西從來沒有被組出來。它剛剛查到的條號、查了幾次,全都只是那個函式裡的區域變數,跟著例外一起消失。
第一版就是這樣,跑起來看到的回應是空的:沒有答案,也沒有出處。停下來是停下來了,但呼叫端什麼都沒拿到。
補救的地方在轉接頭。第一節提過,那支 agent 不會自己去碰檢索,它每次查規章都是呼叫我包的那個轉接頭,由轉接頭去問服務的檢索、再把結果轉成它認得的格式。也就是說,每一條被查出來的條文,一定會經過這個轉接頭。那就讓它在經手的時候順便記下來:
class RetrieverCollection: # 讓那支 agent 以為自己在對資料庫講話
def query(self, query_embeddings, n_results, include=None) -> dict:
hits = self._retriever.search(...) # 真正去查的地方
self.searches += 1 # 今天加的兩行
self.article_nos += [h.meta["article_no"] for h in hits]
return {...} # 轉成它認得的格式
這樣一來,例外跳出去之後,我手上還有一份「它剛剛查到哪些條文、查了幾次」。服務就自己組一包跟正常情況同樣形狀的東西回去,答案那一欄留空。
實際打一次。為了確定一定會觸發,我把時間上限設成 20 毫秒,那大概只夠它查一次規章:
{"answer": null,
"citations": [{"article_no": 24, "title": "病假", ...},
{"article_no": 25, ...}, {"article_no": 29, ...}],
"agent": {"searches": 1, "steps": 1,
"stopped_reason": "time", "stopped_at_step": 2},
"mode": "agent"}
steps 是 1、stopped_at_step 是 2,看起來矛盾,其實是兩件事:它完整走完了第 1 步(問模型、模型說要查規章、查完),正要開始第 2 步的時候被攔下來。所以完成的是 1 步,停在第 2 步。
answer 是 null 而不是一段半成品。它寫到一半的草稿只活在那支 agent 的區域變數裡,跟上面那些條號一樣拿不到,而我不想自己拼一段像答案的東西塞進去。
拿到這包回應的前端可以直接把那三條條文列出來,告訴使用者「這次超時了,但這是它查到的規章」。跟一個全空的回應比起來,這是差很多的兩種畫面。
寫到這裡才輪到狀態碼。直覺是 504 或 429 之類的,被上限攔下來聽起來就該是個錯誤。
但看著上面那包 JSON 就講不下去了。錯誤的格式是 Day 24 定的,裡面只有三個欄位:代號、給人看的訊息、以及這次請求的編號。沒有一個放得下那三條條文。要塞的話就得在錯誤裡開一個放資料的欄位,那等於承認它根本不是錯誤。
另一個角度更直接:狀態碼是給呼叫端判斷「要不要重試」的。同一個請求重試一次,一樣會撞到同一個上限。它不是一個暫時的失敗。
所以圖最底下那一格是兩條出口共用的:被上限攔下來回 200,回應裡多兩個欄位講清楚發生什麼事,一個是為什麼停(步數、時間、金額),一個是停在第幾步。
呼叫端要判斷就讀這兩個欄位,不要讀狀態碼。我對這個決定沒有很有把握,「200 代表成功」是很多人寫 client 時的預設假設,這樣做等於要求對方多讀一層。最後說服我的是那包 JSON 裡的三條出處。
新增 10 個測試,服務那一包從 56 變成 66,全部走既有的快取,不打 API。挑兩個:
def test_default_is_unlimited():
"""預設不能改變既有行為:三個欄位都沒給就是不設限。"""
assert budget.resolve().unlimited
def test_step_cap_does_not_leak(client):
"""改的是模組常數,離開請求要還原,不然下一個人拿到別人的上限。"""
agent_ask(client, question=LONG_QUESTION, max_steps=2)
assert agent21.MAX_STEPS == 6
第二個是今天最該有的一個。上一節那個「在外面改常數」的做法是整個行程共用的,也就是說我改的那一下,同時間進來的別人也會被改到。現在不會出事,是因為 agent 這條路一次只放一個請求進來,而那把鎖是 Day 24 為了量時間才加的,跟上限沒有關係。哪天有人覺得那把鎖沒用把它拿掉,這個測試會先叫。
去確認那把鎖的時候,順手發現另一件事:這支服務的問答本來就是一次一個,因為外面還有一把更大的鎖抓著整段請求。我在一條已經是單行道的路上又立了一個號誌。要真的能同時跑,得把檢索與向量那兩塊注入那支 agent,而那要改它的程式碼,所以今天沒動,只寫進 README 的「還沒做的」。
day24_service/budget.py)